/*-
* Copyright 2010 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.google.authenticator.blackberry;
import net.rim.blackberry.api.browser.Browser;
import net.rim.blackberry.api.browser.BrowserSession;
import net.rim.device.api.i18n.ResourceBundle;
import net.rim.device.api.system.Alert;
import net.rim.device.api.system.Application;
import net.rim.device.api.system.ApplicationDescriptor;
import net.rim.device.api.ui.MenuItem;
import net.rim.device.api.ui.Screen;
import net.rim.device.api.ui.UiApplication;
import net.rim.device.api.ui.component.LabelField;
import net.rim.device.api.ui.component.Menu;
import net.rim.device.api.ui.component.RichTextField;
import net.rim.device.api.ui.container.MainScreen;
import org.bouncycastle.crypto.Mac;
import org.bouncycastle.crypto.digests.SHA1Digest;
import org.bouncycastle.crypto.macs.HMac;
import org.bouncycastle.crypto.params.KeyParameter;
import com.google.authenticator.blackberry.AccountDb.OtpType;
import com.google.authenticator.blackberry.Base32String.DecodingException;
import com.google.authenticator.blackberry.resource.AuthenticatorResource;
/**
* BlackBerry port of {@code AuthenticatorActivity}.
*/
public class AuthenticatorScreen extends MainScreen implements UpdateCallback,
AuthenticatorResource, Runnable {
private static ResourceBundle sResources = ResourceBundle.getBundle(
BUNDLE_ID, BUNDLE_NAME);
private static final int VIBRATE_DURATION = 200;
private static final long REFRESH_INTERVAL = 30 * 1000;
private static final boolean AUTO_REFRESH = true;
private static final String TERMS_URL = "http://www.google.com/accounts/TOS";
private static final String PRIVACY_URL = "http://www.google.com/mobile/privacy.html";
/**
* Computes the one-time PIN given the secret key.
*
* @param secret
* the secret key
* @return the PIN
* @throws GeneralSecurityException
* @throws DecodingException
* If the key string is improperly encoded.
*/
public static String computePin(String secret, Long counter) {
try {
final byte[] keyBytes = Base32String.decode(secret);
Mac mac = new HMac(new SHA1Digest());
mac.init(new KeyParameter(keyBytes));
PasscodeGenerator pcg = new PasscodeGenerator(mac);
if (counter == null) { // time-based totp
return pcg.generateTimeoutCode();
} else { // counter-based hotp
return pcg.generateResponseCode(counter.longValue());
}
} catch (RuntimeException e) {
return "General security exception";
} catch (DecodingException e) {
return "Decoding exception";
}
}
/**
* Parses a secret value from a URI. The format will be:
*
* <pre>
* https://www.google.com/accounts/KeyProv?user=username#secret
* OR
* totp://username@domain#secret
* otpauth://totp/user@example.com?secret=FFF...
* otpauth://hotp/user@example.com?secret=FFF...&counter=123
* </pre>
*
* @param uri The URI containing the secret key
*/
void parseSecret(Uri uri) {
String scheme = uri.getScheme().toLowerCase();
String path = uri.getPath();
String authority = uri.getAuthority();
String user = DEFAULT_USER;
String secret;
AccountDb.OtpType type = AccountDb.OtpType.TOTP;
Integer counter = new Integer(0); // only interesting for HOTP
if (OTP_SCHEME.equals(scheme)) {
if (authority != null && authority.equals(TOTP)) {
type = AccountDb.OtpType.TOTP;
} else if (authority != null && authority.equals(HOTP)) {
type = AccountDb.OtpType.HOTP;
String counterParameter = uri.getQueryParameter(COUNTER_PARAM);
if (counterParameter != null) {
counter = Integer.valueOf(counterParameter);
}
}
if (path != null && path.length() > 1) {
user = path.substring(1); // path is "/user", so remove leading /
}
secret = uri.getQueryParameter(SECRET_PARAM);
// TODO: remove TOTP scheme
} else if (TOTP.equals(scheme)) {
if (authority != null) {
user = authority;
}
secret = uri.getFragment();
} else { // https://www.google.com... URI format
String userParam = uri.getQueryParameter(USER_PARAM);
if (userParam != null) {
user = userParam;
}
secret = uri.getFragment();
}
if (secret == null) {
// Secret key not found in URI
return;
}
// TODO: April 2010 - remove version parameter handling.
String version = uri.getQueryParameter(VERSION_PARAM);
if (version == null) { // version is null for legacy URIs
try {
secret = Base32String.encode(Base32Legacy.decode(secret));
} catch (DecodingException e) {
// Error decoding legacy key from URI
e.printStackTrace();
}
}
if (!secret.equals(getSecret(user)) ||
counter != AccountDb.getCounter(user) ||
type != AccountDb.getType(user)) {
saveSecret(user, secret, null, type);
mStatusText.setText(sResources.getString(SECRET_SAVED));
}
}
static String getSecret(String user) {
return AccountDb.getSecret(user);
}
static void saveSecret(String user, String secret,
String originalUser, AccountDb.OtpType type) {
if (originalUser == null) {
originalUser = user;
}
if (secret != null) {
AccountDb.update(user, secret, originalUser, type);
Alert.startVibrate(VIBRATE_DURATION);
}
}
private LabelField mVersionText;
private LabelField mStatusText;
private RichTextField mEnterPinTextView;
private PinListField mUserList;
private PinListFieldCallback mUserAdapter;
private PinInfo[] mUsers = {};
private boolean mUpdateAvailable;
private int mTimer = -1;
static final String DEFAULT_USER = "Default account";
private static final String OTP_SCHEME = "otpauth";
private static final String TOTP = "totp"; // time-based
private static final String HOTP = "hotp"; // counter-based
private static final String USER_PARAM = "user";
private static final String SECRET_PARAM = "secret";
private static final String VERSION_PARAM = "v";
private static final String COUNTER_PARAM = "counter";
public AuthenticatorScreen() {
setTitle(sResources.getString(APP_NAME));
// LabelField cannot scroll content that is bigger than the screen,
// so use RichTextField instead.
mEnterPinTextView = new RichTextField(sResources.getString(ENTER_PIN));
mUserList = new PinListField();
mUserAdapter = new PinListFieldCallback(mUsers);
setAdapter();
ApplicationDescriptor applicationDescriptor = ApplicationDescriptor
.currentApplicationDescriptor();
String version = applicationDescriptor.getVersion();
mVersionText = new LabelField(version, FIELD_RIGHT | FIELD_BOTTOM);
mStatusText = new LabelField("", FIELD_HCENTER | FIELD_BOTTOM);
add(mEnterPinTextView);
add(mUserList);
add(new LabelField(" ")); // One-line spacer
add(mStatusText);
add(mVersionText);
FieldUtils.setVisible(mEnterPinTextView, false);
UpdateCallback callback = this;
new UpdateTask(callback).start();
}
private void setAdapter() {
int lastIndex = mUserList.getSelectedIndex();
mUserList.setCallback(mUserAdapter);
mUserList.setSize(mUsers.length);
mUserList.setRowHeight(mUserAdapter.getRowHeight());
mUserList.setSelectedIndex(lastIndex);
}
/**
* {@inheritDoc}
*/
protected void onDisplay() {
super.onDisplay();
onResume();
}
/**
* {@inheritDoc}
*/
protected void onExposed() {
super.onExposed();
onResume();
}
/**
* {@inheritDoc}
*/
protected void onObscured() {
onPause();
super.onObscured();
}
private void onResume() {
refreshUserList();
if (AUTO_REFRESH) {
startTimer();
}
}
private void onPause() {
if (isTimerSet()) {
stopTimer();
}
}
private boolean isTimerSet() {
return mTimer != -1;
}
private void startTimer() {
if (isTimerSet()) {
stopTimer();
}
Application application = getApplication();
Runnable runnable = this;
boolean repeat = true;
mTimer = application.invokeLater(runnable, REFRESH_INTERVAL, repeat);
}
private void stopTimer() {
if (isTimerSet()) {
Application application = getApplication();
application.cancelInvokeLater(mTimer);
mTimer = -1;
}
}
/**
* {@inheritDoc}
*/
public void run() {
refreshUserList();
}
void refreshUserList() {
String[] cursor = AccountDb.getNames();
if (cursor.length > 0) {
if (mUsers.length != cursor.length) {
mUsers = new PinInfo[cursor.length];
}
for (int i = 0; i < cursor.length; i++) {
String user = cursor[i];
computeAndDisplayPin(user, i, false);
}
mUserAdapter = new PinListFieldCallback(mUsers);
setAdapter(); // force refresh of display
if (!FieldUtils.isVisible(mUserList)) {
mEnterPinTextView.setText(sResources.getString(ENTER_PIN));
FieldUtils.setVisible(mEnterPinTextView, true);
FieldUtils.setVisible(mUserList, true);
}
} else {
// If the user started up this app but there is no secret key yet,
// then tell the user to visit a web page to get the secret key.
mUsers = new PinInfo[0]; // clear any existing user PIN state
tellUserToGetSecretKey();
}
}
/**
* Tells the user to visit a web page to get a secret key.
*/
private void tellUserToGetSecretKey() {
// TODO: fill this in with code to send our phone number to the server
String notInitialized = sResources.getString(NOT_INITIALIZED);
mEnterPinTextView.setText(notInitialized);
FieldUtils.setVisible(mEnterPinTextView, true);
FieldUtils.setVisible(mUserList, false);
}
/**
* Computes the PIN and saves it in mUsers. This currently runs in the UI
* thread so it should not take more than a second or so. If necessary, we can
* move the computation to a background thread.
*
* @param user the user email to display with the PIN
* @param position the index for the screen of this user and PIN
* @param computeHotp true if we should increment counter and display new hotp
*
* @return the generated PIN
*/
String computeAndDisplayPin(String user, int position, boolean computeHotp) {
OtpType type = AccountDb.getType(user);
String secret = getSecret(user);
PinInfo currentPin;
if (mUsers[position] != null) {
currentPin = mUsers[position]; // existing PinInfo, so we'll update it
} else {
currentPin = new PinInfo();
currentPin.mPin = sResources.getString(EMPTY_PIN);
}
currentPin.mUser = user;
if (type == OtpType.TOTP) {
currentPin.mPin = computePin(secret, null);
} else if (type == OtpType.HOTP) {
currentPin.mIsHotp = true;
if (computeHotp) {
AccountDb.incrementCounter(user);
Integer counter = AccountDb.getCounter(user);
currentPin.mPin = computePin(secret, new Long(counter.longValue()));
}
}
mUsers[position] = currentPin;
return currentPin.mPin;
}
private void pushScreen(Screen screen) {
UiApplication app = (UiApplication) getApplication();
app.pushScreen(screen);
}
/**
* {@inheritDoc}
*/
public Menu getMenu(int instance) {
if (instance == Menu.INSTANCE_CONTEXT) {
// Show the full menu instead of the context menu
return super.getMenu(Menu.INSTANCE_DEFAULT);
} else {
return super.getMenu(instance);
}
}
/**
* {@inheritDoc}
*/
protected void makeMenu(Menu menu, int instance) {
super.makeMenu(menu, instance);
MenuItem enterKeyItem = new MenuItem(sResources, ENTER_KEY_MENU_ITEM, 0, 0) {
public void run() {
pushScreen(new EnterKeyScreen());
}
};
MenuItem termsItem = new MenuItem(sResources, TERMS_MENU_ITEM, 0, 0) {
public void run() {
BrowserSession session = Browser.getDefaultSession();
session.displayPage(TERMS_URL);
}
};
MenuItem privacyItem = new MenuItem(sResources, PRIVACY_MENU_ITEM, 0, 0) {
public void run() {
BrowserSession session = Browser.getDefaultSession();
session.displayPage(PRIVACY_URL);
}
};
menu.add(enterKeyItem);
if (!isTimerSet()) {
MenuItem refreshItem = new MenuItem(sResources, REFRESH_MENU_ITEM, 0, 0) {
public void run() {
refreshUserList();
}
};
menu.add(refreshItem);
}
if (mUpdateAvailable) {
MenuItem updateItem = new MenuItem(sResources, UPDATE_NOW, 0, 0) {
public void run() {
BrowserSession session = Browser.getDefaultSession();
session.displayPage(Build.DOWNLOAD_URL);
mStatusText.setText("");
}
};
menu.add(updateItem);
}
menu.add(termsItem);
menu.add(privacyItem);
}
/**
* {@inheritDoc}
*/
public void onUpdate(String version) {
String status = sResources.getString(UPDATE_AVAILABLE) + ": " + version;
mStatusText.setText(status);
mUpdateAvailable = true;
}
}